- Introduced a new menu item for Mihomo in the app sidebar for easier navigation. - Refactored the Mihomo page component by removing unused imports and state variables, streamlining the code for better performance and readability. - Enhanced the overall structure of the Mihomo page to improve maintainability and user experience.
218 lines
6.9 KiB
Svelte
218 lines
6.9 KiB
Svelte
<script lang="ts">
|
|
import { ApiError, fetchMihomoJson, mihomoPut } from '$lib/api/client.js';
|
|
import type { MihomoProxiesResponse, MihomoProxyEntry } from '$lib/api/mihomo-types.js';
|
|
import { Button } from '$lib/components/ui/button/index.js';
|
|
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';
|
|
|
|
let {
|
|
alias,
|
|
lazy = false
|
|
}: {
|
|
alias: string;
|
|
/** Не запрашивать /proxies до явного нажатия «Загрузить» / «Обновить». */
|
|
lazy?: 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);
|
|
let proxiesErr = $state<string | null>(null);
|
|
let proxiesData = $state<MihomoProxiesResponse | null>(null);
|
|
let switching = $state<string | null>(null);
|
|
let testing = $state<string | null>(null);
|
|
let testingGroup = $state<string | null>(null);
|
|
|
|
function delayQuery(testUrl?: string) {
|
|
const u =
|
|
testUrl && String(testUrl).trim() !== '' ? String(testUrl).trim() : MIHOMO_DELAY_DEFAULT_URL;
|
|
return `timeout=5000&url=${encodeURIComponent(u)}`;
|
|
}
|
|
|
|
async function loadProxies() {
|
|
const a = alias;
|
|
if (!a) return;
|
|
proxiesLoading = true;
|
|
proxiesErr = null;
|
|
try {
|
|
proxiesData = await fetchMihomoJson<MihomoProxiesResponse>(a, 'proxies');
|
|
} catch (e) {
|
|
proxiesData = null;
|
|
proxiesErr = e instanceof ApiError ? e.message : String(e);
|
|
} finally {
|
|
proxiesLoading = false;
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
const a = alias;
|
|
if (!a || lazy) 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;
|
|
if (t === 'loadbalance' && Array.isArray(p.all) && p.all.length > 0) return true;
|
|
return false;
|
|
}
|
|
|
|
function lastDelay(p: MihomoProxyEntry): number | null {
|
|
const h = p.history;
|
|
if (!h?.length) return null;
|
|
return h[h.length - 1]?.delay ?? null;
|
|
}
|
|
|
|
async function selectProxy(groupName: string, nodeName: string) {
|
|
const a = alias;
|
|
if (!a) return;
|
|
switching = groupName;
|
|
try {
|
|
await mihomoPut(a, `proxies/${encodeURIComponent(groupName)}`, { name: nodeName });
|
|
await loadProxies();
|
|
} catch (e) {
|
|
proxiesErr = e instanceof ApiError ? e.message : String(e);
|
|
} finally {
|
|
switching = null;
|
|
}
|
|
}
|
|
|
|
async function pingOne(name: string) {
|
|
const a = alias;
|
|
if (!a) return;
|
|
testing = name;
|
|
try {
|
|
const testUrl = proxiesData?.proxies?.[name]?.testUrl;
|
|
await fetchMihomoJson(
|
|
a,
|
|
`proxies/${encodeURIComponent(name)}/delay?${delayQuery(testUrl)}`
|
|
);
|
|
await loadProxies();
|
|
} catch (e) {
|
|
proxiesErr = e instanceof ApiError ? e.message : String(e);
|
|
} finally {
|
|
testing = null;
|
|
}
|
|
}
|
|
|
|
async function pingGroup(groupName: string) {
|
|
const a = alias;
|
|
if (!a) return;
|
|
testingGroup = groupName;
|
|
const groupTestUrl = proxiesData?.proxies?.[groupName]?.testUrl;
|
|
try {
|
|
await fetchMihomoJson(
|
|
a,
|
|
`group/${encodeURIComponent(groupName)}/delay?${delayQuery(groupTestUrl)}`
|
|
);
|
|
await loadProxies();
|
|
} catch {
|
|
try {
|
|
const g = proxiesData?.proxies?.[groupName];
|
|
const names = g?.all ?? [];
|
|
for (const n of names) {
|
|
const u = proxiesData?.proxies?.[n]?.testUrl;
|
|
await fetchMihomoJson(a, `proxies/${encodeURIComponent(n)}/delay?${delayQuery(u)}`);
|
|
}
|
|
await loadProxies();
|
|
} catch (e) {
|
|
proxiesErr = e instanceof ApiError ? e.message : String(e);
|
|
}
|
|
} finally {
|
|
testingGroup = null;
|
|
}
|
|
}
|
|
</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}>
|
|
Обновить
|
|
</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>
|
|
{/snippet}
|
|
{#snippet children()}
|
|
{#if proxiesData?.proxies}
|
|
<div class="flex flex-col 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}
|
|
>
|
|
<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>
|
|
{/each}
|
|
</div>
|
|
{:else if !lazy || proxiesLoading}
|
|
<!-- lazy без данных: ждём клика; скелетон выше -->
|
|
{:else}
|
|
<p class="text-muted-foreground text-sm">Нажмите «Загрузить прокси», чтобы получить список групп.</p>
|
|
{/if}
|
|
{/snippet}
|
|
</DataQueryState>
|