Enhance Mihomo proxy groups component with new features and improved UI
- Added new props to the MihomoProxyGroups component for better control over loading states and toolbar visibility. - Implemented a refresh button in the Mihomo page to manually trigger proxy updates, enhancing user interaction. - Updated the layout and styling of proxy groups for improved readability and user experience. - Enhanced loading indicators to provide clearer feedback during data fetching operations.
This commit is contained in:
@@ -7,14 +7,24 @@
|
||||
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import ActivityIcon from '@lucide/svelte/icons/activity';
|
||||
|
||||
let {
|
||||
alias,
|
||||
/** Плотная сетка мелких ячеек — для флота и списков из многих нод. */
|
||||
compact = false
|
||||
compact = false,
|
||||
/** Скрыть верхнюю панель «Обновить» (кнопка в Card.Action у родителя). */
|
||||
hideToolbar = false,
|
||||
/** Увеличьте, чтобы принудительно перезагрузить список снаружи. */
|
||||
refreshNonce = 0,
|
||||
/** Состояние загрузки для индикатора у родителя. */
|
||||
onLoadingChange
|
||||
}: {
|
||||
alias: string;
|
||||
compact?: boolean;
|
||||
hideToolbar?: boolean;
|
||||
refreshNonce?: number;
|
||||
onLoadingChange?: (loading: boolean) => void;
|
||||
} = $props();
|
||||
|
||||
const MIHOMO_DELAY_DEFAULT_URL = 'https://www.gstatic.com/generate_204';
|
||||
@@ -36,6 +46,7 @@
|
||||
const a = alias;
|
||||
if (!a) return;
|
||||
proxiesLoading = true;
|
||||
onLoadingChange?.(true);
|
||||
proxiesErr = null;
|
||||
try {
|
||||
proxiesData = await fetchMihomoJson<MihomoProxiesResponse>(a, 'proxies');
|
||||
@@ -44,11 +55,13 @@
|
||||
// не затираем успешный снимок при сбое обновления — без мерцания UI
|
||||
} finally {
|
||||
proxiesLoading = false;
|
||||
onLoadingChange?.(false);
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const a = alias;
|
||||
void refreshNonce;
|
||||
if (!a) return;
|
||||
void loadProxies();
|
||||
});
|
||||
@@ -136,26 +149,28 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<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}
|
||||
{#if !hideToolbar}
|
||||
<div
|
||||
class={cn(
|
||||
'flex flex-wrap items-center justify-end gap-1.5',
|
||||
compact ? 'mb-2' : 'mb-4'
|
||||
)}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
class={cn('mr-1 size-3.5 shrink-0', proxiesLoading && 'animate-spin')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Обновить
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class={cn(compact ? 'h-7 gap-1.5 px-2 text-xs' : 'gap-2')}
|
||||
onclick={() => loadProxies()}
|
||||
disabled={proxiesLoading || !alias}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
class={cn('size-3.5 shrink-0', proxiesLoading && 'animate-spin')}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
Обновить
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<DataQueryState
|
||||
err={proxiesErr}
|
||||
@@ -165,9 +180,9 @@
|
||||
>
|
||||
{#snippet skeleton()}
|
||||
{#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" />
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each Array.from({ length: 10 }) as _, i (i)}
|
||||
<Skeleton class="h-8 w-[5.5rem] rounded-md" />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
@@ -180,77 +195,87 @@
|
||||
{/snippet}
|
||||
{#snippet children()}
|
||||
{#if proxiesData?.proxies}
|
||||
<div class={cn('flex flex-col', compact ? 'gap-3' : 'gap-8')}>
|
||||
<div class={cn('flex flex-col', compact ? 'gap-2' : 'gap-8')}>
|
||||
{#each selectableGroups as [gName, group] (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
|
||||
>
|
||||
<span class="text-muted-foreground text-[11px] sm:text-xs">
|
||||
→ <span class="text-foreground">{group.now ?? '—'}</span>
|
||||
<div
|
||||
class="bg-card/40 ring-border/60 space-y-2 rounded-lg p-2 ring-1 sm:p-2.5"
|
||||
role="group"
|
||||
aria-label="Группа {gName}"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<Badge variant="outline" class="h-5 px-1.5 font-mono text-[10px] uppercase tracking-wide">
|
||||
{gName}
|
||||
</Badge>
|
||||
<span class="text-muted-foreground text-xs">
|
||||
<span class="text-foreground/90 font-medium">{group.now ?? '—'}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-6 shrink-0 px-2 text-[11px]"
|
||||
class="h-7 shrink-0 px-2 text-xs"
|
||||
onclick={() => pingGroup(gName)}
|
||||
disabled={testingGroup === gName}
|
||||
>
|
||||
{testingGroup === gName ? '…' : 'Все delay'}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-wrap gap-1"
|
||||
role="group"
|
||||
aria-label="Узлы группы {gName}"
|
||||
>
|
||||
<div class="flex flex-wrap gap-1.5" role="list">
|
||||
{#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'
|
||||
'bg-background focus-within:ring-ring flex min-w-0 max-w-full items-stretch overflow-hidden rounded-md border text-left text-xs shadow-sm transition-colors focus-within:ring-2',
|
||||
active
|
||||
? 'border-primary bg-accent/25'
|
||||
: 'border-border hover:border-border/80 hover:bg-muted/40'
|
||||
)}
|
||||
role="listitem"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="hover:bg-muted/60 min-w-0 flex-1 px-2 py-1 text-left transition-colors disabled:opacity-50"
|
||||
class="hover:bg-muted/50 min-w-0 flex-1 px-2 py-1.5 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="truncate font-medium leading-none">{nodeName}</div>
|
||||
<div
|
||||
class="text-muted-foreground mt-0.5 flex items-center justify-between gap-1 text-[10px] leading-none"
|
||||
class="text-muted-foreground mt-1 flex items-center justify-between gap-2 text-[10px] leading-none"
|
||||
>
|
||||
<span class="truncate">{node?.type ?? '—'}</span>
|
||||
<span class="truncate opacity-90">{node?.type ?? '—'}</span>
|
||||
<span
|
||||
class={cn(
|
||||
d != null && d < 500 ? 'text-emerald-500 shrink-0' : 'shrink-0'
|
||||
'tabular-nums',
|
||||
d != null && d < 500 ? 'text-emerald-600 dark:text-emerald-400' : ''
|
||||
)}
|
||||
>
|
||||
{d != null ? `${d}` : '—'}<span class="text-muted-foreground/80">ms</span>
|
||||
{d != null ? `${d}` : '—'}<span class="text-muted-foreground/70">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"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="text-muted-foreground hover:text-foreground size-8 shrink-0 rounded-none border-l px-0"
|
||||
onclick={(ev) => {
|
||||
ev.stopPropagation();
|
||||
void pingOne(nodeName);
|
||||
}}
|
||||
disabled={testing === nodeName}
|
||||
aria-label="Задержка для {nodeName}"
|
||||
title="Проверить задержку"
|
||||
>
|
||||
{testing === nodeName ? '…' : 'ping'}
|
||||
</button>
|
||||
{#if testing === nodeName}
|
||||
<span class="text-[10px]">…</span>
|
||||
{:else}
|
||||
<ActivityIcon class="size-3.5 opacity-70" aria-hidden="true" />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import DataQueryState from '$lib/components/fleet/data-query-state.svelte';
|
||||
import MihomoProxyGroups from '$lib/components/mihomo/mihomo-proxy-groups.svelte';
|
||||
import { Skeleton } from '$lib/components/ui/skeleton/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import ServerIcon from '@lucide/svelte/icons/server';
|
||||
import PlugIcon from '@lucide/svelte/icons/plug';
|
||||
@@ -48,6 +49,13 @@
|
||||
/** Список alias с рабочим meta; обновляется только при полной перезагрузке. */
|
||||
let mihomoOkAliases: string[] = [];
|
||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||
/** Принудительная перезагрузка /proxies для карточки ноды. */
|
||||
let proxyRefreshNonce = $state<Record<string, number>>({});
|
||||
let nodeProxyLoading = $state<Record<string, boolean>>({});
|
||||
|
||||
function bumpProxyRefresh(a: string) {
|
||||
proxyRefreshNonce = { ...proxyRefreshNonce, [a]: (proxyRefreshNonce[a] ?? 0) + 1 };
|
||||
}
|
||||
|
||||
let withMihomo = $derived(rows.filter((r) => r.metaStatus === 'ok').length);
|
||||
let aliveCount = $derived(rows.filter((r) => r.metaStatus === 'ok' && r.alive).length);
|
||||
@@ -325,8 +333,9 @@
|
||||
<div class="mt-8 space-y-3">
|
||||
<div>
|
||||
<h2 class="text-base font-semibold tracking-tight">Прокси по нодам</h2>
|
||||
<p class="text-muted-foreground mt-0.5 text-xs">
|
||||
Список групп подгружается автоматически. Клик по ячейке — выбор узла; «ping» — задержка.
|
||||
<p class="text-muted-foreground mt-0.5 max-w-2xl text-xs leading-relaxed">
|
||||
Автозагрузка групп. Клик по узлу — выбор; иконка справа — проверка задержки. Обновление списка — в
|
||||
шапке карточки.
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
@@ -336,18 +345,50 @@
|
||||
<Card.Root
|
||||
id="mihomo-proxies-{encodeURIComponent(r.alias)}"
|
||||
data-size="sm"
|
||||
class="border-l-4 border-l-primary/30 min-h-0 min-w-0 scroll-mt-20 shadow-sm"
|
||||
class="bg-card/50 min-h-0 min-w-0 scroll-mt-20 gap-0 overflow-hidden pt-0 shadow-sm ring-1 ring-border/60"
|
||||
>
|
||||
<Card.Header class="space-y-0 pb-1 pt-3">
|
||||
<Card.Title class="text-sm font-semibold">{r.alias}</Card.Title>
|
||||
<Card.Header class="border-border/80 border-b px-3 pb-3 pt-3 sm:px-4">
|
||||
<Card.Title class="font-mono text-sm font-semibold leading-none tracking-tight">
|
||||
{r.alias}
|
||||
</Card.Title>
|
||||
{#if r.meta?.controller_base}
|
||||
<Card.Description class="font-mono text-[11px] leading-snug">
|
||||
<Card.Description
|
||||
class="text-muted-foreground line-clamp-2 pt-1 font-mono text-[10px] leading-snug"
|
||||
>
|
||||
{r.meta.controller_base}
|
||||
</Card.Description>
|
||||
{/if}
|
||||
<Card.Action>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
class="size-8 shrink-0"
|
||||
aria-label="Обновить прокси для {r.alias}"
|
||||
disabled={!!nodeProxyLoading[r.alias]}
|
||||
onclick={() => bumpProxyRefresh(r.alias)}
|
||||
>
|
||||
<RefreshCwIcon
|
||||
class={cn(
|
||||
'size-3.5',
|
||||
nodeProxyLoading[r.alias] ? 'animate-spin' : ''
|
||||
)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</Button>
|
||||
</Card.Action>
|
||||
</Card.Header>
|
||||
<Card.Content class="px-3 pb-3 pt-0 sm:px-4">
|
||||
<MihomoProxyGroups alias={r.alias} compact={true} />
|
||||
<Card.Content class="px-0 pb-0 pt-0">
|
||||
<div class="px-2 py-2 sm:px-3 sm:py-2.5">
|
||||
<MihomoProxyGroups
|
||||
alias={r.alias}
|
||||
compact={true}
|
||||
hideToolbar={true}
|
||||
refreshNonce={proxyRefreshNonce[r.alias] ?? 0}
|
||||
onLoadingChange={(v) => {
|
||||
nodeProxyLoading = { ...nodeProxyLoading, [r.alias]: v };
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/each}
|
||||
|
||||
Reference in New Issue
Block a user