Add Mac client mode and simplify local proxy UI
This commit is contained in:
256
src/web/components/ClientOverviewPage.jsx
Normal file
256
src/web/components/ClientOverviewPage.jsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { flagFor } from '../utils/country.js';
|
||||
import { formatBytes, formatRelative } from '../utils/format.js';
|
||||
|
||||
function CopyField({ label, value }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1400);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="copy-field">
|
||||
<div>
|
||||
<small className="muted">{label}</small>
|
||||
<div className="text-mono">{value}</div>
|
||||
</div>
|
||||
<button className="btn btn-secondary sm" onClick={copy}>
|
||||
{copied ? 'Скопировано' : 'Копировать'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientHero({ state, status, activeServer }) {
|
||||
const cfg = {
|
||||
running: {
|
||||
title: 'Локальный proxy работает',
|
||||
hint: activeServer ? `Подключен сервер ${activeServer.tag}` : 'Сервер применён',
|
||||
badge: 'Готов',
|
||||
kind: 'success',
|
||||
},
|
||||
applying: {
|
||||
title: 'Применяем сервер',
|
||||
hint: 'sing-box перезапускается',
|
||||
badge: 'Применяем',
|
||||
kind: 'warning',
|
||||
},
|
||||
error: {
|
||||
title: 'Нужна проверка',
|
||||
hint: 'Откройте логи и config',
|
||||
badge: 'Ошибка',
|
||||
kind: 'danger',
|
||||
},
|
||||
stopped: {
|
||||
title: 'Proxy остановлен',
|
||||
hint: 'Конфиг есть, sing-box не запущен',
|
||||
badge: 'Остановлен',
|
||||
kind: 'warning',
|
||||
},
|
||||
no_config: {
|
||||
title: 'Proxy ещё не настроен',
|
||||
hint: 'Загрузите подписку и выберите сервер',
|
||||
badge: 'Не настроен',
|
||||
kind: 'neutral',
|
||||
},
|
||||
}[status] || {};
|
||||
|
||||
const userInfo = state?.userInfo;
|
||||
const traffic = userInfo
|
||||
? `${formatBytes((userInfo.upload || 0) + (userInfo.download || 0))}${userInfo.total ? ' / ' + formatBytes(userInfo.total) : ''}`
|
||||
: 'нет данных';
|
||||
|
||||
return (
|
||||
<section className="client-hero">
|
||||
<div className="client-hero-main">
|
||||
<span className={`badge ${cfg.kind}`}>{cfg.badge}</span>
|
||||
<h1>{cfg.title}</h1>
|
||||
<p>{cfg.hint}</p>
|
||||
</div>
|
||||
<div className="client-hero-meta">
|
||||
<div>
|
||||
<small className="muted">Активный сервер</small>
|
||||
<strong>{activeServer ? `${flagFor(activeServer)} ${activeServer.tag}` : state?.selectedTag || 'Не выбран'}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small className="muted">Трафик</small>
|
||||
<strong>{traffic}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<small className="muted">Применено</small>
|
||||
<strong>{state?.appliedAt ? formatRelative(state.appliedAt) : 'ещё нет'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientSetup({
|
||||
state,
|
||||
servers,
|
||||
subscriptionUrl,
|
||||
setSubscriptionUrl,
|
||||
pendingTag,
|
||||
setPendingTag,
|
||||
busy,
|
||||
onFetchSubscription,
|
||||
onApply,
|
||||
}) {
|
||||
const selected = pendingTag || state?.selectedTag || '';
|
||||
const canApply = selected && selected !== state?.selectedTag;
|
||||
|
||||
return (
|
||||
<div className="card client-setup">
|
||||
<div className="card-header">
|
||||
<h2>Подключение</h2>
|
||||
{state?.hasSubscription && <span className="badge success">Подписка загружена</span>}
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">URL подписки или VLESS-ссылка</label>
|
||||
<div className="subscription-input">
|
||||
<input
|
||||
className="input"
|
||||
placeholder="https://… или vless://…"
|
||||
value={subscriptionUrl}
|
||||
onChange={(e) => setSubscriptionUrl(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && subscriptionUrl && onFetchSubscription()}
|
||||
/>
|
||||
<button className="btn btn-primary" disabled={busy || !subscriptionUrl} onClick={onFetchSubscription}>
|
||||
Загрузить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label">VPN-сервер</label>
|
||||
<div className="subscription-input">
|
||||
<select
|
||||
className="select"
|
||||
value={selected}
|
||||
disabled={!servers.length}
|
||||
onChange={(e) => setPendingTag(e.target.value)}
|
||||
>
|
||||
<option value="">Выберите сервер</option>
|
||||
{servers.map((server) => (
|
||||
<option key={server.tag} value={server.tag}>
|
||||
{flagFor(server)} {server.tag}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button className="btn btn-secondary" disabled={busy || !canApply} onClick={() => onApply(selected)}>
|
||||
Применить
|
||||
</button>
|
||||
</div>
|
||||
<small className="field-hint">
|
||||
В Mac-клиенте весь трафик, который приложение отправит в proxy, идёт через выбранный VPN.
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxyCard({ state }) {
|
||||
const port = state?.proxyPort || 8080;
|
||||
const urls = useMemo(() => ({
|
||||
http: `http://127.0.0.1:${port}`,
|
||||
socks: `socks5://127.0.0.1:${port}`,
|
||||
}), [port]);
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h2>Локальный proxy</h2>
|
||||
<span className="badge info">127.0.0.1:{port}</span>
|
||||
</div>
|
||||
<div className="copy-stack">
|
||||
<CopyField label="HTTP / HTTPS" value={urls.http} />
|
||||
<CopyField label="SOCKS5" value={urls.socks} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientFlow({ state, activeServer }) {
|
||||
const steps = [
|
||||
{ label: 'Mac', value: 'приложения' },
|
||||
{ label: 'Локальный proxy', value: `127.0.0.1:${state?.proxyPort || 8080}` },
|
||||
{ label: 'VPN-сервер', value: activeServer?.tag || state?.selectedTag || 'не выбран' },
|
||||
{ label: 'Интернет', value: state?.singboxRunning ? 'через VPN' : 'ожидает' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header"><h2>Цепочка подключения</h2></div>
|
||||
<div className="client-flow">
|
||||
{steps.map((step, index) => (
|
||||
<React.Fragment key={step.label}>
|
||||
<div className="flow-node">
|
||||
<small>{step.label}</small>
|
||||
<strong>{step.value}</strong>
|
||||
</div>
|
||||
{index < steps.length - 1 && <span className="flow-arrow">→</span>}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ClientActions({ state, busy, onRestart, onStop }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-header"><h2>Управление</h2></div>
|
||||
<div className="btn-group">
|
||||
<button className="btn btn-secondary" disabled={busy || !state?.configExists} onClick={onRestart}>Перезапустить</button>
|
||||
<button className="btn btn-ghost" disabled={busy || !state?.singboxRunning} onClick={onStop}>Остановить</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ClientOverviewPage({
|
||||
state,
|
||||
status,
|
||||
activeServer,
|
||||
busy,
|
||||
subscriptionUrl,
|
||||
setSubscriptionUrl,
|
||||
servers,
|
||||
pendingTag,
|
||||
setPendingTag,
|
||||
onFetchSubscription,
|
||||
onApply,
|
||||
onRestart,
|
||||
onStop,
|
||||
}) {
|
||||
return (
|
||||
<div className="section-stack">
|
||||
<ClientHero state={state} status={status} activeServer={activeServer} />
|
||||
<ClientSetup
|
||||
state={state}
|
||||
servers={servers}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
setSubscriptionUrl={setSubscriptionUrl}
|
||||
pendingTag={pendingTag}
|
||||
setPendingTag={setPendingTag}
|
||||
busy={busy}
|
||||
onFetchSubscription={onFetchSubscription}
|
||||
onApply={onApply}
|
||||
/>
|
||||
<div className="grid-2">
|
||||
<ProxyCard state={state} />
|
||||
<ClientActions
|
||||
state={state}
|
||||
busy={busy}
|
||||
onRestart={onRestart}
|
||||
onStop={onStop}
|
||||
/>
|
||||
</div>
|
||||
<ClientFlow state={state} activeServer={activeServer} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user